Media Integration Documentation
Overview
The Atom SaaS platform provides integrated media control capabilities for Spotify and Apple Music, enabling agents and users to control playback, manage playlists, and discover music through a unified API.
Features
- **Playback Control**: Play, pause, skip, seek, volume control, device transfer
- **Voice Commands**: Natural language processing for media control
- **Playlist Management**: Create, read, update, delete playlists across providers
- **Music Discovery**: Recommendation engine with seed-based, genre-based, and mood-based strategies
- **Multi-Provider Support**: Unified API for Spotify and Apple Music with automatic provider detection
- **Agent Integration**: Agents can control media and manage playlists as part of workflows
Architecture
┌─────────────────┐
│ Frontend UI │
│ (Next.js) │
└────────┬────────┘
│
▼
┌─────────────────────────────────┐
│ Media Service Layer │
│ - PlaybackService │
│ - PlaylistService │
│ - RecommendationService │
└────────┬────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ Integration Clients │
│ - SpotifyClient │
│ - AppleMusicClient │
└────────┬────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ External APIs │
│ - Spotify Web API │
│ - Apple Music API │
└─────────────────────────────────┘OAuth Setup
Spotify
- **Create Spotify App**
- Go to https://developer.spotify.com/dashboard
- Click "Create App"
- Set app name and description
- **Configure Redirect URI**
- Edit Settings → Redirect URIs
- Add:
https://your-domain.com/api/integrations/spotify/callback
- **Enable Required Scopes**
user-read-playback-state- Read playback stateuser-modify-playback-state- Control playbackuser-read-currently-playing- Read current trackuser-read-email- Read user emailplaylist-read-private- Read private playlistsplaylist-modify-public- Modify public playlistsplaylist-modify-private- Modify private playlistsstreaming- Stream music
- **Set Environment Variables**
Apple Music
- **Create MusicKit Key**
- Go to https://developer.apple.com/account/resources
- Certificates, Identifiers & Profiles → Keys → Create Key
- Configure MusicKit
- Download .p8 file (can only download once!)
- **Generate JWT Developer Token**
- The backend automatically generates JWT tokens using the private key
- Tokens are valid for 6 months
- Tokens are regenerated automatically when expired
- **Set Environment Variables**
**Important**: Use escaped newlines (\n) in the private key for environment variables.
API Reference
Playback Control
Get Current Playback State
GET /api/media/playback?provider=spotify**Response:**
{
"success": true,
"data": {
"state": {
"isPlaying": true,
"currentTrack": {
"name": "Blinding Lights",
"artist": "The Weeknd",
"album": "After Hours",
"uri": "spotify:track:0VjIjW4GlUZAMYd2vXMi3b",
"artworkUrl": "https://i.scdn.co/image/...",
"durationMs": 200000
},
"progressMs": 45000,
"durationMs": 200000,
"volumePercent": 75,
"deviceId": "device-123",
"provider": "spotify"
}
}
}Execute Playback Action
POST /api/media/playback
Content-Type: application/json
{
"action": "play",
"value": "spotify:playlist:37i9dQZF1DX4sWSpwq3LiO",
"provider": "spotify"
}**Actions:**
play- Start playback (optionalvalue: track/playlist URI)pause- Pause playbacknext- Skip to next trackprevious- Go to previous trackvolume- Set volume (value: 0-100)seek- Seek to position (value: milliseconds)transfer- Transfer playback to device (value: device ID)search- Search for tracks (value: search query)getDevices- List available devices
**Response:**
{
"success": true,
"data": {
"executed": true,
"state": { /* PlaybackState */ }
}
}Voice Commands
POST /api/media/voice
Content-Type: application/json
{
"command": "play some jazz music",
"provider": "spotify"
}**Response:**
{
"success": true,
"data": {
"parsed": {
"original": "play some jazz music",
"action": "play",
"value": "jazz music",
"confidence": 0.85,
"provider": "spotify"
},
"state": { /* PlaybackState */ }
}
}**Supported Patterns:**
play <track/artist/genre>- Play musicpause/stop- Pause playbackskip/next- Next trackprevious/back- Previous trackvolume <0-100>%- Set volumeturn it up/turn it down- Adjust volumeplay <track> by <artist>- Play specific tracksearch for <query>- Search music
Playlists
List Playlists
GET /api/media/playlists?provider=spotify**Response:**
{
"success": true,
"data": {
"playlists": [
{
"id": "playlist-123",
"name": "My Favorites",
"provider": "spotify",
"trackCount": 50,
"isPublic": false,
"artworkUrl": "https://example.com/art.jpg"
}
]
}
}Create Playlist
POST /api/media/playlists
Content-Type: application/json
{
"name": "Road Trip Mix",
"description": "Best driving songs",
"isPublic": false,
"provider": "spotify",
"trackUris": ["spotify:track:1", "spotify:track:2"]
}Add Tracks to Playlist
PUT /api/media/playlists
Content-Type: application/json
{
"action": "add_tracks",
"playlistId": "playlist-123",
"trackUris": ["spotify:track:3", "spotify:track:4"]
}Remove Tracks from Playlist
PUT /api/media/playlists
Content-Type: application/json
{
"action": "remove_tracks",
"playlistId": "playlist-123",
"trackUris": ["spotify:track:3"]
}Get Playlist Tracks
GET /api/media/playlists/playlist-123/tracksDelete Playlist
DELETE /api/media/playlists?playlistId=playlist-123Recommendations
Generate Recommendations
POST /api/media/recommendations
Content-Type: application/json
{
"seedTracks": ["spotify:track:1", "spotify:track:2"],
"seedArtists": ["spotify:artist:123"],
"limit": 10,
"provider": "spotify"
}**Response:**
{
"success": true,
"data": {
"tracks": [
{
"uri": "spotify:track:rec1",
"name": "Recommended Song",
"artist": "Artist Name",
"album": "Album Name",
"artworkUrl": "https://...",
"durationMs": 180000
}
],
"source": "api",
"confidence": 0.7
}
}Genre-Based Recommendations
GET /api/media/recommendations/genre/rock?provider=spotify&limit=20Mood-Based Recommendations
GET /api/media/recommendations/mood/happy?provider=spotify&limit=10**Supported Moods:**
happy→ Upbeat, energeticsad→ Melancholic, emotionalfocus→ Ambient, instrumentalrelax→ Calm, peacefulenergetic→ High energy, upbeatchill→ Laid back, mellow
Get Recommendation History
GET /api/media/recommendations?limit=20Submit Feedback
POST /api/media/recommendations/feedback
Content-Type: application/json
{
"recommendationId": "rec-123",
"score": 0.8,
"notes": "Great recommendations!",
"category": "accuracy"
}**Score Range:** -1.0 (negative) to +1.0 (positive)
Agent Integration
Skill Example
import { PlaybackService } from '@/lib/media/playback-service';
import { PlaylistService } from '@/lib/media/playlist-service';
// Agent skill to play focus music
async function playFocusMusic(tenantId: string) {
const playbackService = new PlaybackService(tenantId);
// Play focus playlist
await playbackService.execute({
type: 'play',
value: 'spotify:playlist:37i9dQZF1DX4sWSpwq3LiO', // Focus Flow
provider: 'spotify',
});
return {
success: true,
message: 'Playing focus music',
};
}
// Agent skill to create workout playlist
async function createWorkoutPlaylist(tenantId: string, trackUris: string[]) {
const playlistService = new PlaylistService(tenantId);
const playlist = await playlistService.createPlaylist({
name: 'Workout Mix',
description: 'High-energy workout songs',
isPublic: false,
provider: 'spotify',
trackUris,
});
return {
success: true,
playlistId: playlist.id,
trackCount: trackUris.length,
};
}Voice Commands in Agents
// Agent can process natural language commands
async function processMediaCommand(tenantId: string, command: string) {
const response = await fetch('/api/media/voice', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command }),
});
const result = await response.json();
if (result.success) {
return {
understood: true,
action: result.data.parsed.action,
confidence: result.data.parsed.confidence,
};
}
return {
understood: false,
suggestion: 'Try saying "play jazz" or "pause the music"',
};
}Recommendation Generation
import { RecommendationService } from '@/lib/media/recommendation-service';
// Generate personalized recommendations
async function getDailyMix(tenantId: string) {
const recommendationService = new RecommendationService(tenantId);
// Based on listening history
const result = await recommendationService.generateRecommendations({
// No seeds = analyze history
});
return result.tracks;
}
// Genre-based discovery
async function discoverGenre(tenantId: string, genre: string) {
const recommendationService = new RecommendationService(tenantId);
const result = await recommendationService.generateRecommendations({
genre,
limit: 20,
});
return result.tracks;
}Database Schema
media_playlists
Stores playlist metadata synced from external providers.
CREATE TABLE media_playlists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
provider VARCHAR(20) NOT NULL, -- 'spotify' | 'apple-music'
external_id VARCHAR(255) NOT NULL, -- Provider's playlist ID
name VARCHAR(255) NOT NULL,
description TEXT,
is_public BOOLEAN DEFAULT false,
track_count INTEGER DEFAULT 0,
artwork_url TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(tenant_id, provider, external_id)
);
CREATE INDEX idx_media_playlists_tenant ON media_playlists(tenant_id);
CREATE INDEX idx_media_playlists_provider ON media_playlists(provider);
CREATE INDEX idx_media_playlists_updated ON media_playlists(updated_at DESC);
-- RLS Policy
ALTER TABLE media_playlists ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON media_playlists
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);media_playlist_tracks
Stores track metadata for playlist contents.
CREATE TABLE media_playlist_tracks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
playlist_id UUID NOT NULL REFERENCES media_playlists(id) ON DELETE CASCADE,
track_uri TEXT NOT NULL,
track_name VARCHAR(255),
artist_names TEXT[],
album_name VARCHAR(255),
duration_ms INTEGER,
artwork_url TEXT,
position INTEGER,
added_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(playlist_id, track_uri)
);
CREATE INDEX idx_playlist_tracks_playlist ON media_playlist_tracks(playlist_id);
CREATE INDEX idx_playlist_tracks_tenant ON media_playlist_tracks(tenant_id);
-- RLS Policy
ALTER TABLE media_playlist_tracks ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON media_playlist_tracks
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);media_recommendations
Stores recommendation history and metadata.
CREATE TABLE media_recommendations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
seed_track_uri TEXT,
seed_artist_names TEXT[],
recommended_track_uris TEXT[] NOT NULL,
provider VARCHAR(20) NOT NULL,
source VARCHAR(20) NOT NULL, -- 'api' | 'history' | 'genre' | 'mood'
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW(),
valid_for INTEGER DEFAULT 86400 -- Cache validity (seconds)
);
CREATE INDEX idx_recommendations_tenant ON media_recommendations(tenant_id);
CREATE INDEX idx_recommendations_created ON media_recommendations(created_at DESC);
-- RLS Policy
ALTER TABLE media_recommendations ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON media_recommendations
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);media_recommendation_feedback
Stores user feedback for recommendations.
CREATE TABLE media_recommendation_feedback (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
recommendation_id UUID NOT NULL REFERENCES media_recommendations(id) ON DELETE CASCADE,
score NUMERIC(3, 2) NOT NULL CHECK (score BETWEEN -1 AND 1),
notes TEXT,
category VARCHAR(50), -- 'accuracy' | 'helpfulness' | 'diversity'
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_feedback_recommendation ON media_recommendation_feedback(recommendation_id);
CREATE INDEX idx_feedback_tenant ON media_recommendation_feedback(tenant_id);
-- RLS Policy
ALTER TABLE media_recommendation_feedback ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON media_recommendation_feedback
FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);Governance
Agent Maturity Restrictions
Media control actions respect agent maturity levels:
- **Student Agents**: Read-only access (getState, search, listPlaylists, getPlaylistTracks)
- **Intern Agents**: Full control with supervisor approval (playback, playlist modifications)
- **Supervised Agents**: Full control with live monitoring
- **Autonomous Agents**: Full control without restrictions
Required Actions
Agents need the following actions:
MEDIA_CONTROL_PLAYBACK- Play/pause/skip/volume/seekMEDIA_PLAYLIST_MANAGE- Create/modify/delete playlistsMEDIA_READ_STATE- Read playback state and device info
Rate Limiting
Media API calls respect tenant rate limits:
- **Free Tier**: 50 calls/day
- **Solo Tier**: 500 calls/day
- **Team Tier**: 5,000 calls/day
- **Enterprise Tier**: Unlimited
Troubleshooting
OAuth Errors
**Error:** Spotify token exchange failed
- **Cause:** Invalid authorization code or redirect URI mismatch
- **Fix:** Verify redirect URI matches Spotify app settings exactly
**Error:** Apple Music configuration missing
- **Cause:** Missing or invalid environment variables
- **Fix:** Verify
APPLE_MUSIC_KEY_ID,APPLE_MUSIC_TEAM_ID,APPLE_MUSIC_PRIVATE_KEYare set
Token Refresh Failures
**Error:** Spotify token refresh failed
- **Cause:** Refresh token expired or revoked
- **Fix:** User must re-authenticate through OAuth flow
**Error:** Developer token generation failed
- **Cause:** Invalid private key or key ID
- **Fix:** Verify private key format and key ID match Apple Developer portal
Rate Limiting
**Error:** Spotify API error: 429
- **Cause:** Too many API requests
- **Fix:** Implement exponential backoff, retry after
Retry-Afterheader
Device Not Found
**Error:** Device not found
- **Cause:** Device ID invalid or device offline
- **Fix:** Use
getDevicesto list available devices, transfer to active device
Playlist Sync Failures
**Error:** Failed to sync playlists from Spotify
- **Cause:** Network error or API rate limit
- **Fix:** Retry sync operation, check connection status
Recommendation Issues
**Error:** No seeds provided and no history available
- **Cause:** New account with no listening history
- **Fix:** Provide seed tracks or artists explicitly
**Error:** Genre not found
- **Cause:** Invalid genre name
- **Fix:** Use common genre names (rock, jazz, pop, hip-hop, electronic)
Testing
Run Media Tests
# Client tests
npm run test -- spotify.unit.test
npm run test -- apple-music.unit.test
# Service tests
npm run test -- playback-service.unit.test
npm run test -- playlist-service.unit.test
npm run test -- recommendation-service.unit.test
# API tests
npm run test -- playback.test.ts
npm run test -- playlists.test.ts
npm run test -- voice.test.tsTest Coverage
# Generate coverage report
npm run test:coverage
# View HTML report
open coverage/index.html
# Verify coverage thresholds
# - SpotifyClient: >90%
# - AppleMusicClient: >85%
# - Media services: >80%Mock Configuration
Tests use comprehensive mocks for external dependencies:
// Mock fetch for API calls
global.fetch = vi.fn();
// Mock database
vi.mock('@/lib/database');
vi.mocked(getDatabase).mockReturnValue(mockDb);
// Mock provider clients
vi.mock('@/lib/integrations/spotify');
vi.mock('@/lib/integrations/apple-music');Best Practices
Provider Auto-Detection
Let the service detect the active provider automatically:
// Good - auto-detect
const state = await playbackService.getState();
// Specify only when needed
const state = await playbackService.getState('spotify');Error Handling
Always handle provider-specific errors:
try {
await playbackService.execute({ type: 'play', provider: 'spotify' });
} catch (error) {
if (error instanceof MediaIntegrationError) {
console.error('Provider not connected:', error.provider);
} else if (error instanceof MediaPlaybackError) {
console.error('Playback failed:', error.action);
}
}Feedback Loop
Submit feedback to improve recommendations:
await recommendationService.submitFeedback({
recommendationId: result.id,
score: 0.9, // Positive
notes: 'Great mix of similar artists',
category: 'accuracy',
});Cache Invalidation
Genre and mood playlists are cached for 24 hours. Negative feedback invalidates cache:
// Submits -0.5 score, which deletes cached recommendation
await recommendationService.submitFeedback({
recommendationId: cachedRec.id,
score: -0.5,
});Platform Evolution
Phase 63A-01 Status
**Completed:**
- ✅ OAuth integration for Spotify and Apple Music (Plan 01)
- ✅ Unified playback service and voice commands (Plan 02)
- ✅ Playlist management and music recommendations (Plan 03)
- ✅ Test coverage and documentation (Plan 04) - **Current**
**Next Enhancements:**
- YouTube Music integration
- Advanced voice command parsing (NLP/ML)
- Multi-device synchronization
- Lyrics fetching and display
- Social sharing of playlists
- Real-time collaborative playlists
Support
For issues or questions:
- Check troubleshooting section above
- Review test files for usage examples
- Check Spotify/Apple Music API documentation
- Verify environment variables and OAuth configuration
---
*Last Updated: 2026-02-20*
*Version: 1.0.0*